@@ -74,6 +74,8 @@ gem 'slack-notifier', '~> 0.5.0' |
||
| 74 | 74 |
|
| 75 | 75 |
gem 'therubyracer', '~> 0.12.1' |
| 76 | 76 |
|
| 77 |
+gem 'mqtt' |
|
| 78 |
+ |
|
| 77 | 79 |
group :development do |
| 78 | 80 |
gem 'binding_of_caller' |
| 79 | 81 |
gem 'better_errors' |
@@ -160,6 +160,7 @@ GEM |
||
| 160 | 160 |
mime-types (1.25.1) |
| 161 | 161 |
mini_portile (0.5.3) |
| 162 | 162 |
minitest (5.3.3) |
| 163 |
+ mqtt (0.2.0) |
|
| 163 | 164 |
multi_json (1.9.3) |
| 164 | 165 |
multi_xml (0.5.5) |
| 165 | 166 |
multipart-post (2.0.0) |
@@ -341,6 +342,7 @@ DEPENDENCIES |
||
| 341 | 342 |
kaminari (~> 0.15.1) |
| 342 | 343 |
kramdown (~> 1.3.3) |
| 343 | 344 |
liquid (~> 2.6.1) |
| 345 |
+ mqtt |
|
| 344 | 346 |
mysql2 (~> 0.3.15) |
| 345 | 347 |
nokogiri (~> 1.6.1) |
| 346 | 348 |
protected_attributes (~> 1.0.7) |
@@ -0,0 +1,138 @@ |
||
| 1 |
+# encoding: utf-8 |
|
| 2 |
+require "mqtt" |
|
| 3 |
+require "json" |
|
| 4 |
+ |
|
| 5 |
+module Agents |
|
| 6 |
+ class MqttAgent < Agent |
|
| 7 |
+ description <<-MD |
|
| 8 |
+ The MQTT agent allows both publication and subscription to an MQTT topic. |
|
| 9 |
+ |
|
| 10 |
+ MQTT is a generic transport protocol for machine to machine communication. |
|
| 11 |
+ |
|
| 12 |
+ You can do things like: |
|
| 13 |
+ |
|
| 14 |
+ * Publish to [RabbitMQ](http://www.rabbitmq.com/mqtt.html) |
|
| 15 |
+ * Run [OwnTracks, a location tracking tool](http://owntracks.org/) for iOS and Android |
|
| 16 |
+ * Subscribe to your home automation setup like [Ninjablocks](http://forums.ninjablocks.com/index.php?p=/discussion/661/today-i-learned-about-mqtt/p1) or [TheThingSystem](http://thethingsystem.com/dev/supported-things.html) |
|
| 17 |
+ |
|
| 18 |
+ Simply choose a topic (think email subject line) to publish/listen to, and configure your service. |
|
| 19 |
+ |
|
| 20 |
+ It's easy to setup your own [broker](http://jpmens.net/2013/09/01/installing-mosquitto-on-a-raspberry-pi/) or connect to a [cloud service](www.cloudmqtt.com) |
|
| 21 |
+ |
|
| 22 |
+ Hints: |
|
| 23 |
+ Many services run mqtts (mqtt over SSL) often with a custom certificate. |
|
| 24 |
+ |
|
| 25 |
+ You'll want to download their cert and install it locally, specifying the ```certificate_path``` configuration. |
|
| 26 |
+ |
|
| 27 |
+ |
|
| 28 |
+ Example configuration: |
|
| 29 |
+ |
|
| 30 |
+ <pre><code>{
|
|
| 31 |
+ 'uri' => 'mqtts://user:pass@locahost:8883' |
|
| 32 |
+ 'ssl' => :TLSv1, |
|
| 33 |
+ 'ca_file' => './ca.pem', |
|
| 34 |
+ 'cert_file' => './client.crt', |
|
| 35 |
+ 'key_file' => './client.key', |
|
| 36 |
+ 'topic' => 'huginn' |
|
| 37 |
+ } |
|
| 38 |
+ </code></pre> |
|
| 39 |
+ |
|
| 40 |
+ Subscribe to CloCkWeRX's TheThingSystem instance (thethingsystem.com), where |
|
| 41 |
+ temperature and other events are being published. |
|
| 42 |
+ |
|
| 43 |
+ <pre><code>{
|
|
| 44 |
+ 'uri' => 'mqtt://kcqlmkgx:sVNoccqwvXxE@m10.cloudmqtt.com:13858', |
|
| 45 |
+ 'topic' => 'the_thing_system/demo' |
|
| 46 |
+ } |
|
| 47 |
+ </code></pre> |
|
| 48 |
+ |
|
| 49 |
+ Subscribe to all topics |
|
| 50 |
+ <pre><code>{
|
|
| 51 |
+ 'uri' => 'mqtt://kcqlmkgx:sVNoccqwvXxE@m10.cloudmqtt.com:13858', |
|
| 52 |
+ 'topic' => '/#' |
|
| 53 |
+ } |
|
| 54 |
+ </code></pre> |
|
| 55 |
+ |
|
| 56 |
+ Find out more detail on [subscription wildcards](http://www.eclipse.org/paho/files/mqttdoc/Cclient/wildcard.html) |
|
| 57 |
+ MD |
|
| 58 |
+ |
|
| 59 |
+ event_description <<-MD |
|
| 60 |
+ Events are simply nested MQTT payloads. For example, an MQTT payload for Owntracks |
|
| 61 |
+ |
|
| 62 |
+ <pre><code>{
|
|
| 63 |
+ "topic": "owntracks/kcqlmkgx/Dan", |
|
| 64 |
+ "message": {"_type": "location", "lat": "-34.8493644", "lon": "138.5218119", "tst": "1401771049", "acc": "50.0", "batt": "31", "desc": "Home", "event": "enter"},
|
|
| 65 |
+ "time": 1401771051 |
|
| 66 |
+ }</code></pre> |
|
| 67 |
+ MD |
|
| 68 |
+ |
|
| 69 |
+ def validate_options |
|
| 70 |
+ unless options['uri'].present? && |
|
| 71 |
+ options['topic'].present? |
|
| 72 |
+ errors.add(:base, "topic and uri are required") |
|
| 73 |
+ end |
|
| 74 |
+ end |
|
| 75 |
+ |
|
| 76 |
+ def working? |
|
| 77 |
+ event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs? |
|
| 78 |
+ end |
|
| 79 |
+ |
|
| 80 |
+ def default_options |
|
| 81 |
+ {
|
|
| 82 |
+ 'uri' => 'mqtts://user:pass@locahost:8883', |
|
| 83 |
+ 'ssl' => :TLSv1, |
|
| 84 |
+ 'ca_file' => './ca.pem', |
|
| 85 |
+ 'cert_file' => './client.crt', |
|
| 86 |
+ 'key_file' => './client.key', |
|
| 87 |
+ 'topic' => 'huginn', |
|
| 88 |
+ 'max_read_time' => '10' |
|
| 89 |
+ } |
|
| 90 |
+ end |
|
| 91 |
+ |
|
| 92 |
+ def mqtt_client |
|
| 93 |
+ @client ||= MQTT::Client.new(options['uri']) |
|
| 94 |
+ |
|
| 95 |
+ if options['ssl'] |
|
| 96 |
+ @client.ssl = options['ssl'].to_sym |
|
| 97 |
+ @client.ca_file = options['ca_file'] |
|
| 98 |
+ @client.cert_file = options['cert_file'] |
|
| 99 |
+ @client.key_file = options['key_file'] |
|
| 100 |
+ end |
|
| 101 |
+ |
|
| 102 |
+ @client |
|
| 103 |
+ end |
|
| 104 |
+ |
|
| 105 |
+ def receive(incoming_events) |
|
| 106 |
+ mqtt_client.connect do |c| |
|
| 107 |
+ incoming_events.each do |event| |
|
| 108 |
+ c.publish(options['topic'], payload) |
|
| 109 |
+ end |
|
| 110 |
+ |
|
| 111 |
+ c.disconnect |
|
| 112 |
+ end |
|
| 113 |
+ end |
|
| 114 |
+ |
|
| 115 |
+ |
|
| 116 |
+ def check |
|
| 117 |
+ mqtt_client.connect do |c| |
|
| 118 |
+ |
|
| 119 |
+ Timeout::timeout((options['max_read_time'].presence || 15).to_i) {
|
|
| 120 |
+ c.get(options['topic']) do |topic, message| |
|
| 121 |
+ |
|
| 122 |
+ # A lot of services generate JSON. Try that first |
|
| 123 |
+ payload = JSON.parse(message) rescue message |
|
| 124 |
+ |
|
| 125 |
+ create_event :payload => {
|
|
| 126 |
+ 'topic' => topic, |
|
| 127 |
+ 'message' => payload, |
|
| 128 |
+ 'time' => Time.now.to_i |
|
| 129 |
+ } |
|
| 130 |
+ end |
|
| 131 |
+ } rescue TimeoutError |
|
| 132 |
+ |
|
| 133 |
+ c.disconnect |
|
| 134 |
+ end |
|
| 135 |
+ end |
|
| 136 |
+ |
|
| 137 |
+ end |
|
| 138 |
+end |
@@ -0,0 +1,52 @@ |
||
| 1 |
+require 'spec_helper' |
|
| 2 |
+require 'mqtt' |
|
| 3 |
+require './spec/support/fake_mqtt_server' |
|
| 4 |
+ |
|
| 5 |
+describe Agents::MqttAgent do |
|
| 6 |
+ |
|
| 7 |
+ before :each do |
|
| 8 |
+ @error_log = StringIO.new |
|
| 9 |
+ |
|
| 10 |
+ @server = MQTT::FakeServer.new(41234, '127.0.0.1') |
|
| 11 |
+ @server.just_one = true |
|
| 12 |
+ @server.logger = Logger.new(@error_log) |
|
| 13 |
+ @server.logger.level = Logger::DEBUG |
|
| 14 |
+ @server.start |
|
| 15 |
+ |
|
| 16 |
+ @valid_params = {
|
|
| 17 |
+ 'uri' => "mqtt://#{@server.address}:#{@server.port}",
|
|
| 18 |
+ 'topic' => '/#', |
|
| 19 |
+ 'max_read_time' => '1', |
|
| 20 |
+ 'expected_update_period_in_days' => "2" |
|
| 21 |
+ } |
|
| 22 |
+ |
|
| 23 |
+ @checker = Agents::MqttAgent.new( |
|
| 24 |
+ :name => "somename", |
|
| 25 |
+ :options => @valid_params, |
|
| 26 |
+ :schedule => "midnight", |
|
| 27 |
+ ) |
|
| 28 |
+ @checker.user = users(:jane) |
|
| 29 |
+ @checker.save! |
|
| 30 |
+ end |
|
| 31 |
+ |
|
| 32 |
+ after :each do |
|
| 33 |
+ @server.stop |
|
| 34 |
+ end |
|
| 35 |
+ |
|
| 36 |
+ describe "#check" do |
|
| 37 |
+ it "should check that initial run creates an event" do |
|
| 38 |
+ expect { @checker.check }.to change { Event.count }.by(2)
|
|
| 39 |
+ end |
|
| 40 |
+ end |
|
| 41 |
+ |
|
| 42 |
+ describe "#working?" do |
|
| 43 |
+ it "checks if its generating events as scheduled" do |
|
| 44 |
+ @checker.should_not be_working |
|
| 45 |
+ @checker.check |
|
| 46 |
+ @checker.reload.should be_working |
|
| 47 |
+ three_days_from_now = 3.days.from_now |
|
| 48 |
+ stub(Time).now { three_days_from_now }
|
|
| 49 |
+ @checker.should_not be_working |
|
| 50 |
+ end |
|
| 51 |
+ end |
|
| 52 |
+end |
@@ -0,0 +1,137 @@ |
||
| 1 |
+#!/usr/bin/env ruby |
|
| 2 |
+# |
|
| 3 |
+# This is a 'fake' MQTT server to help with testing client implementations |
|
| 4 |
+# |
|
| 5 |
+# See https://github.com/njh/ruby-mqtt/blob/master/spec/fake_server.rb |
|
| 6 |
+# |
|
| 7 |
+# It behaves in the following ways: |
|
| 8 |
+# * Responses to CONNECT with a successful CONACK |
|
| 9 |
+# * Responses to PUBLISH by echoing the packet back |
|
| 10 |
+# * Responses to SUBSCRIBE with SUBACK and a PUBLISH to the topic |
|
| 11 |
+# * Responses to PINGREQ with PINGRESP |
|
| 12 |
+# * Responses to DISCONNECT by closing the socket |
|
| 13 |
+# |
|
| 14 |
+# It has the following restrictions |
|
| 15 |
+# * Doesn't deal with timeouts |
|
| 16 |
+# * Only handles a single connection at a time |
|
| 17 |
+# |
|
| 18 |
+ |
|
| 19 |
+$:.unshift File.dirname(__FILE__)+'/../lib' |
|
| 20 |
+ |
|
| 21 |
+require 'logger' |
|
| 22 |
+require 'socket' |
|
| 23 |
+require 'mqtt' |
|
| 24 |
+ |
|
| 25 |
+ |
|
| 26 |
+class MQTT::FakeServer |
|
| 27 |
+ attr_reader :address, :port |
|
| 28 |
+ attr_reader :last_publish |
|
| 29 |
+ attr_reader :thread |
|
| 30 |
+ attr_reader :pings_received |
|
| 31 |
+ attr_accessor :just_one |
|
| 32 |
+ attr_accessor :logger |
|
| 33 |
+ |
|
| 34 |
+ # Create a new fake MQTT server |
|
| 35 |
+ # |
|
| 36 |
+ # If no port is given, bind to a random port number |
|
| 37 |
+ # If no bind address is given, bind to localhost |
|
| 38 |
+ def initialize(port=nil, bind_address='127.0.0.1') |
|
| 39 |
+ @port = port |
|
| 40 |
+ @address = bind_address |
|
| 41 |
+ end |
|
| 42 |
+ |
|
| 43 |
+ # Get the logger used by the server |
|
| 44 |
+ def logger |
|
| 45 |
+ @logger ||= Logger.new(STDOUT) |
|
| 46 |
+ end |
|
| 47 |
+ |
|
| 48 |
+ # Start the thread and open the socket that will process client connections |
|
| 49 |
+ def start |
|
| 50 |
+ @socket ||= TCPServer.new(@address, @port) |
|
| 51 |
+ @address = @socket.addr[3] |
|
| 52 |
+ @port = @socket.addr[1] |
|
| 53 |
+ @thread ||= Thread.new do |
|
| 54 |
+ logger.info "Started a fake MQTT server on #{@address}:#{@port}"
|
|
| 55 |
+ loop do |
|
| 56 |
+ # Wait for a client to connect |
|
| 57 |
+ client = @socket.accept |
|
| 58 |
+ @pings_received = 0 |
|
| 59 |
+ handle_client(client) |
|
| 60 |
+ break if just_one |
|
| 61 |
+ end |
|
| 62 |
+ end |
|
| 63 |
+ end |
|
| 64 |
+ |
|
| 65 |
+ # Stop the thread and close the socket |
|
| 66 |
+ def stop |
|
| 67 |
+ logger.info "Stopping fake MQTT server" |
|
| 68 |
+ @socket.close unless @socket.nil? |
|
| 69 |
+ @socket = nil |
|
| 70 |
+ |
|
| 71 |
+ @thread.kill if @thread and @thread.alive? |
|
| 72 |
+ @thread = nil |
|
| 73 |
+ end |
|
| 74 |
+ |
|
| 75 |
+ # Start the server thread and wait for it to finish (possibly never) |
|
| 76 |
+ def run |
|
| 77 |
+ start |
|
| 78 |
+ begin |
|
| 79 |
+ @thread.join |
|
| 80 |
+ rescue Interrupt |
|
| 81 |
+ stop |
|
| 82 |
+ end |
|
| 83 |
+ end |
|
| 84 |
+ |
|
| 85 |
+ |
|
| 86 |
+ protected |
|
| 87 |
+ |
|
| 88 |
+ # Given a client socket, process MQTT packets from the client |
|
| 89 |
+ def handle_client(client) |
|
| 90 |
+ loop do |
|
| 91 |
+ packet = MQTT::Packet.read(client) |
|
| 92 |
+ logger.debug packet.inspect |
|
| 93 |
+ |
|
| 94 |
+ case packet |
|
| 95 |
+ when MQTT::Packet::Connect |
|
| 96 |
+ client.write MQTT::Packet::Connack.new(:return_code => 0) |
|
| 97 |
+ when MQTT::Packet::Publish |
|
| 98 |
+ client.write packet |
|
| 99 |
+ @last_publish = packet |
|
| 100 |
+ when MQTT::Packet::Subscribe |
|
| 101 |
+ client.write MQTT::Packet::Suback.new( |
|
| 102 |
+ :message_id => packet.message_id, |
|
| 103 |
+ :granted_qos => 0 |
|
| 104 |
+ ) |
|
| 105 |
+ topic = packet.topics[0][0] |
|
| 106 |
+ client.write MQTT::Packet::Publish.new( |
|
| 107 |
+ :topic => topic, |
|
| 108 |
+ :payload => "hello #{topic}",
|
|
| 109 |
+ :retain => true |
|
| 110 |
+ ) |
|
| 111 |
+ client.write MQTT::Packet::Publish.new( |
|
| 112 |
+ :topic => topic, |
|
| 113 |
+ :payload => "did you know about #{topic}",
|
|
| 114 |
+ :retain => true |
|
| 115 |
+ ) |
|
| 116 |
+ |
|
| 117 |
+ when MQTT::Packet::Pingreq |
|
| 118 |
+ client.write MQTT::Packet::Pingresp.new |
|
| 119 |
+ @pings_received += 1 |
|
| 120 |
+ when MQTT::Packet::Disconnect |
|
| 121 |
+ client.close |
|
| 122 |
+ break |
|
| 123 |
+ end |
|
| 124 |
+ end |
|
| 125 |
+ |
|
| 126 |
+ rescue MQTT::ProtocolException => e |
|
| 127 |
+ logger.warn "Protocol error, closing connection: #{e}"
|
|
| 128 |
+ client.close |
|
| 129 |
+ end |
|
| 130 |
+ |
|
| 131 |
+end |
|
| 132 |
+ |
|
| 133 |
+if __FILE__ == $0 |
|
| 134 |
+ server = MQTT::FakeServer.new(MQTT::DEFAULT_PORT) |
|
| 135 |
+ server.logger.level = Logger::DEBUG |
|
| 136 |
+ server.run |
|
| 137 |
+end |